Opalia · Private creator servers

Frontend architecture proposal

Route map, role model, component inventory and data contracts — for sign-off before any screen gets built. Built on your uploaded clean-room roleplay shell, styled only with design-system tokens so the palette stays an open decision.

Rev {{ revision }} Stack: Next.js app router · TS Theme: tokens only, palette TBD
01

Decisions taken from your answers

Hero journey

Fan: invite → apply → wait → approved → first session. Reads end-to-end in the prototype as one continuous click-path.

Full hi-fi first pass

Creator dashboard · Server home · Character card management · Extended roleplay page. Fan-flow screens ride along because the journey needs them.

Copy

One language at a time, switched from the header (EN / 中文) — never both on screen. Every string ships as an EN/ZH pair, so the switch is a data change, not a screen fork.

Roleplay layout

Immersive: messages float over the character art, exactly as your upload does. Rail, right panel and mobile drawer preserved.

Review layout (my call)

List + detail split on desktop, full-height drawer on mobile. Full trust & safety panel inside the detail column.

Gates

Invite code, creator questions, rules agreement, moderator approval, membership expiry, per-character grants — all mandatory. External account link becomes per-server optional.

Mock states

One states gallery screen holds every loading / empty / error / denied / expired / revoked variant, so product screens stay clean.

Colour

Light-blue accent, applied as a token retune only: the ramp is overridden at :root and every surface still reads var(--color-*). Swapping the theme stays a token edit, not a screen edit.

02

What the upload gives us, and what gets added

Kept from app/page.tsx + globals.css
ICON RAIL
topbar · identityprofile ⇄ chat
immersive scene · character art
composer (UI layer only)
detail panel

Three-column grid, panel open/closed/auto tri-state, profile ⇄ chat mode switch, 980px drawer breakpoint, 640px bottom-nav breakpoint, reduced-motion guard. All of it stays; the CSS variables at the top of globals.css are re-pointed at design-system tokens.

Added on top
  • Server switcher above the rail, with current-server indicator
  • Character switcher + session history in a second rail column
  • New session action; session list scoped to server + character
  • Membership access state — banner when expiring, lock when revoked
  • Model selector placeholder and conversation settings drawer
  • Access-denied variant of the whole shell for non-members
  • Composer split into Composer (view) + useSession() (future API seam)
Clean-room rule

No names, art, descriptions or messages carried over from any third-party product. Every character, server and message in the prototype is invented placeholder content, and art is a drawn placeholder rather than an image.

03

Route map

app router · route groups in parentheses

(public)

No session required
/ landing · value prop, invite-code field, approval explainer
/invite/[code] invitation landing · public-safe server facts only
/legal/[doc] terms, privacy, platform rules

(auth)

Carries ?next= so an invite link survives sign-in
/signin · /signup
/recover · /recover/[token]
/verify email/code confirmation
/link/[provider] generic external-account binding

(apply)

Signed in, not yet a member
/invite/[code]/apply the application form
/applications my applications
/applications/[id] status · 7 states

(member)

Approved membership required per server
/home across all joined servers
/s/[server] server home
/s/[server]/c/[char] character profile
/s/[server]/c/[char]/[session] roleplay
/sessions · /settings/[tab]

(studio)

Owner or moderator; capability-checked per action
/studio server picker + create
/studio/[server] overview dashboard
/studio/[server]/characters/[id]/[tab]
/studio/[server]/applications/[id]?
/studio/[server]/members · /invitations
/studio/[server]/announcements · /settings

(admin)

Platform administrator only
/admin/servers · /admin/creators
/admin/reports · /admin/audit

Out of scope for the first prototype pass — routes reserved so the role model stays complete.

04

Access gates

an invitation buys the right to apply, nothing more
01
Invitation

Code valid, unexpired, under application cap.

02
Platform account

Signed in and verified.

03
External account

Per-server setting — required, optional, or off. Generic provider slots.

04
Application

Creator questions answered, rules agreed, source recorded.

05
Approval

Owner or moderator decides. Then membership, group, expiry and per-character grants.

05

Role → navigation

nav is derived from capabilities, never hardcoded per page
Nav item Admin Owner Moderator Member Applicant Banned
Home 首页●●●●——
My servers 我的服务器●●●●——
Characters 角色●●●● granted only——
Sessions 会话●●●●——
Applications 申请●●●●●● read-only
Account settings 账号设置●●●●●●
Studio overview●●————
Character cards●●● edit, no publish———
Applications review●●●———
Members●●● no ban———
Invitations●●●———
Announcements●●●———
Server settings●●————
06

Component inventory

page content never owns styling; primitives never own domain logic
components/ui — primitives
ButtonIconButtonAvatarStatusBadgeTagFieldValidationMessageTextInputTextareaSelectCheckboxSegmentedTabsTablePaginationSearchFilterBarConfirmDialogSideDrawerToastSkeletonEmptyStateAccessDeniedBlueprintCoverPlaceholder
components/domain
ServerCardCharacterCardInvitationCardMemberRowApplicantRowApplicationDetailApplicationTimelineExternalAccountBadgeRiskSignalPanelGateChecklistReviewActionBarSessionRowAnnouncementBlockRulesListAccessMatrixMembershipBannerStatTileActivityFeed
components/shell + chat
AppShellIconRailServerSwitcherCharacterSwitcherMobileTabBarStudioShellStudioSidebarScreenHeaderRoleNavDetailPanelSceneStageMessageListMessageBubbleComposerModelSelectorConversationSettingsSessionHistory
07

Data contracts & mock service seam

type Role = 'admin' | 'owner' | 'moderator' | 'member'
          | 'applicant' | 'rejected' | 'banned'

type AppStatus = 'draft' | 'pending' | 'info_required'
               | 'approved' | 'rejected' | 'expired' | 'revoked'

interface Server {
  id, slug, name, tagline, coverRef
  creator: CreatorRef        // verified: boolean
  rules: string[]
  externalAccount: 'required' | 'optional' | 'off'
  questions: Question[]
  memberGroups: MemberGroup[]
}

interface Invitation {
  code, serverId, createdBy
  expiresAt, maxApplications, usedCount
  singleUse: boolean
  requiredProvider?: ProviderId
  defaultGroupId?: string
  disabled: boolean
}

interface Application {
  id, serverId, applicantId, invitationCode
  status: AppStatus
  displayName, reason, referrer?
  answers: Answer[]
  externalAccount?: ExternalAccountRef
  risk: RiskSignal[]          // server-computed
  notes: ReviewNote[]         // staff-only
  submittedAt, decidedAt?, decidedBy?
}

interface CharacterCard {
  id, serverId, name, blurb, avatarRef, coverRef
  tags: string[]
  visibility: 'draft' | 'published' | 'archived'
  groupIds: string[]
  version: number
  ragCorpusLimitTokens: 60000
  secondaryKnowledgeEnabled: boolean
  ragSearchLimitTokens?: number // 100..14500; omitted = auto
  preStatusBarPrompt?: string   // legacy import only; editor migrates and clears it
  postStatusBarPrompt?: string  // legacy import only; editor migrates and clears it
  preStatusBarVariables?: StatusVariable[]
  postStatusBarVariables?: StatusVariable[]
  preStatusBarCss?: string      // presentation-only; declarations only
  postStatusBarCss?: string     // presentation-only; declarations only
  globalThemeCss?: string       // presentation-only; parsed + scoped
  theaterHtml?: string          // presentation-only; hostile micro-app
  hasSystemPrompt: boolean    // never the prompt itself
  promptRef: string           // opaque server handle
  model: ModelSettings        // display-safe subset
}

type StatusVariable =
  | { id, name, source: 'ai', instruction }
  | { id, name, source: 'code',
      codeOperation: 'turn_increment' | 'random_character',
      increment?: 1..99,
      characters?: string /* legacy key: comma-separated string choices */ }
// There is deliberately no arbitrary code/source field.
Service layer

One lib/api/*.ts module per resource, each exporting async functions with the real signature and a mock implementation behind USE_MOCKS. Screens import the function, never the fixture. Swapping to real endpoints touches only these files.

Chat seam

Composer and MessageList are pure views over useSession(), which today appends to local state and returns a canned reply. A streaming transport drops in behind the same hook.

Prompt safety

Model keys and server prompts never enter a client payload. A live creator preview may send its request-owned draft RAG text to the authenticated preview endpoint; persisted roleplay uses opaque server references.

Creator RAG budget contract

The Secondary information section sits immediately after Writing & roleplay requirements and before Opening & state. It contains automatic token planning, one enable/disable switch, and the three RAG sources: World lore, Character secondary settings, and Trigger Memory. It defaults on for new cards and legacy drafts without an explicit switch value; the author must actively turn it off. Turning it off preserves authored text but sends secondaryKnowledgeEnabled: false, which makes backend retrieval and injection budgets zero. The sources share a 60,000-token corpus limit. One optional per-turn search limit remains inside this section; when omitted the backend computes max(100, ceil(corpusTokens / 100) × 10). Recommend self-contained 50–150-token entries in the form type/title → keywords or aliases → concrete fact, with blank lines between entries and explicit repeated proper nouns. World core exposes only World directive and World description; the editor no longer asks for a separate world name. Writing & roleplay requirements are fixed every-turn structured context with narration, tone, enactment, AI status-variable definitions, output format and avoid rules. Status-variable name, fill type, detail and create action share one desktop row; narrow screens wrap it. There is no shared AI-rule field: legacy shared rules migrate into per-variable instructions and the old field is cleared. Local status variables remain browser-only. The UI no longer exposes Other requirements or When it applies. These fixed model-visible fields consume their actual size before enabled retrieval layers use the remaining 14,500-token author pool. Together with world core and character core they receive explicit Bedrock Claude prompt-cache checkpoints; AI status-variable definitions are part of the writing-and-enactment checkpoint. The complete model input is capped at 45,000 tokens and the current user message has its own 2,000-token ceiling. The authoring UI shows per-field token usage but never renders the assembled fixed prompt/XML. New cards and legacy drafts missing a scope default all three RAG cognition scopes to Public facts, while explicitly stored choices remain unchanged. Opening & state exposes exactly two author fields: authoritative state facts and an opening message capped at 4,000 content tokens; the opening enters history and state uses its separate 2,500-token ceiling. Mini theater & appearance follows Opening & state.

Presentation-code boundary

Mini theater appears first and is handled as hostile code in srcdoc with sandbox="allow-scripts" only, an opaque origin, a deny-by-default CSP and a source/origin/channel-checked height-only postMessage protocol. It never receives allow-same-origin. Remote resources, nested frames, object/embed, external scripts, navigation, forms, clipboard, parent DOM, workers and dynamic-code APIs are stripped or browser-blocked. Global beautification follows it and is a restricted theme, not arbitrary page CSS: only the thirteen named #opalia_*/.opalia_* hooks are accepted, every selector is prefixed with #opalia_roleplay_root, and external URLs, global selectors, overlays, z-index, pointer interception, hiding, clipping/masking and unbounded animation are removed. Pre- and post-response status bars use named variables. AI variables send only a name and escaped fill instruction; browser variables are a closed enum containing a system-named round increment with an integer step from 1 through 99 and a system-named deterministic selection of one complete string from comma-separated candidates. On desktop the New row gives each input a content-weighted track and keeps the New action compact; on mobile every control becomes a full-width row. The action and inputs use border-box sizing and the same 44px control height. Added variables appear below the CSS editor as blue capsules that show only their variable names. Clicking one inserts a non-editable blue field with that same name at the current cursor; the editor safely serializes it as a known var(--opalia-status-*) token without parsing authored HTML. Visible text is composed only by the reserved --opalia-status-text declaration, whose parser accepts quoted literals and known variable references without evaluating code. AI definitions are escaped into fixed structured XML and count against the shared author pool; local operations and CSS never enter a model request or token budget. Model-generated JSON values are rendered as text before or after narration. If card persistence moves server-side, this same presentation policy must be revalidated on write and again on render.

08

Prototype build plan

one file, screen router, role switcher
A · fan journey

Landing → invitation → application → status (pending → approved) → enter server. Clickable straight through.

B · member

Server home with announcement, character collection, recent sessions, membership status.

C · roleplay

Your shell, extended: server + character switchers, sessions, model placeholder, settings drawer, working composer.

D · studio

Dashboard, character card editor with eight form sections, review split view, invitations, members.

E · states

Gallery of all ten mock states plus the shared empty / skeleton / denied components.

Decide before I build
  • Studio nav: sidebar per server (my default) or a global sidebar with a server dropdown?
  • Does a member see other members at all, or is the roster creator-only?
  • Are sessions private to the fan, or can a creator inspect transcripts? Big trust question.
Deliberately out of scope this pass
  • Platform admin screens (routes reserved)
  • Payments, tipping, subscription tiers
  • Announcement composer, notification centre
  • Real auth, real chat transport, moderation pipeline